#!/usr/bin/env python
'''
    actioncal.py
    ActionCal - A Simple Calendar

    Copyright (C) 2006, 2007  R. P. Dillon

    This program is free software; you can redistribute it and/or modify
    it under the terms of version 2 the GNU General Public License as
    published by the Free Software Foundation.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program; if not, write to the Free Software
    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
'''

# Some local imports for the project
# We use everything in these modules
from events import *
from model import *

# Python imports
from Tkconstants import *
import ConfigParser
import datetime
import Tkinter
import os
import sys

######################################################################
# Configuration
# TODO: A lot of these should be pushed out to the config file.
######################################################################
APP_TITLE = "ActionCal"
SAVE_FILE = "actioncal.dat"
CONFIG_FILE = "actioncal.conf"
GUI_SECTION = "GUI"
WINDOW_GEOMETRY = "WindowGeometry"
PLUGIN_DIR = "plugins"

# Colors #
MONTH_BG = "#fffacd"
EVENTS_BG = "#ffffff"
DAY_TITLE_BG = "#8b1a1a"
DAY_TITLE_ACTIVE_BG = "#1a1a8b"
DAY_TITLE_FG = "#fffacd"
WEEKDAY_HEADER_BG = "#cccccc"

######################################################################
# View
######################################################################

class CalendarView(object):

    # Should be localized, but this will get us pretty far before people complain.
    weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]

    # The minimum size in pixels of the calendar columns.
    columnminsize = 100

    activedate = datetime.date.today()

    def __init__(self, manager):
        """
        A whole lot of initialization.
        """

        self.readconfig()
        self.root = Tkinter.Tk()

        self.root.geometry(self.windowgeo)
        
        self.root.title(APP_TITLE)
        self.manager = manager
        manager.register(self)

        # Set up internal keyboard shortcuts
        self.createKeyboardShortcuts()
        
        # Load any available plugins
        self.initializePlugins()

        # Get month data for each day in the current month (the month
        # in which the active date falls)
        self.manager.post(requestmonthdataevent(self.activedate))

        # Boolean that stores whether or not the user has editted any
        # data since the last save
        self.editted = False

        # Boolean that tracks whether the user is currently typing
        # into one of the day boxes.
        self.editting = False

        self.root.protocol('WM_DELETE_WINDOW', self.quit)
        self.drawcal()
        self.root.grid()
        self.root.mainloop()

    def createKeyboardShortcuts(self):
        """
        Sets up some keyboard shortcuts.

        These are only for internal use.
        """
        self.root.bind("<Prior>",     lambda(event): self.backmonth())
        self.root.bind("<Next>",      lambda(event): self.forwardmonth())
        self.root.bind("<Control-q>", lambda(event): self.quit())
        self.root.bind("<Control-s>", lambda(event): self.save())
        self.root.bind("<Right>",     lambda(event): self.move(datetime.timedelta(days=1)))
        self.root.bind("<Left>",      lambda(event): self.move(datetime.timedelta(days=-1)))
        self.root.bind("<Up>",        lambda(event): self.move(datetime.timedelta(days=-7)))
        self.root.bind("<Down>",      lambda(event): self.move(datetime.timedelta(days=7)))
        self.root.bind("<F2>",        lambda(event): self.days[self.activedate].takefocus())
        self.root.bind("<Escape>",    lambda(event): self.releaseedit())


    # Some utility methods
    def currentmonthstr(self):
        """
        Return the name of the month, as a string (i.e. "January")
        in which the active date falls.
        """
        return self.activedate.strftime("%B")

    def currentyearstr(self):
        """
        Returns the year in which the active date falls as a string.
        """
        return self.activedate.strftime("%Y")

    # Event handling
    def handle(self, event):
        """
        Handles events from the eventmanager and takes appropriate
        action.
        """
        if isinstance(event, monthdataevent):
            self.eventlist = event.data
        elif isinstance(event, startedittingevent):
            if not self.editted:
                self.root.title(APP_TITLE + " - Unsaved")
                self.editted = True
            self.editting = True
        elif isinstance(event, stopedittingevent):
            self.editting = False
        elif isinstance(event, setactivedateevent):
            self.jumptodate(event.date)
        elif isinstance(event, KeyBindEvent):
            self.root.bind(event.key, event.function)
        elif isinstance(event, SyncEvent):
            self.synctocal()
            
    def releaseedit(self):
        """
        A simple method that releases edit focus and notifies listeners
        that we are no longer editting.
        """
        self.manager.post(stopedittingevent())
        self.root.focus()
            
    # Configuration methods
    def readconfig(self):
        """
        Reads some cofiguration options from the default configuration
        file.
        """
        if os.path.isfile(CONFIG_FILE):
            parser = ConfigParser.ConfigParser()
            parser.read(CONFIG_FILE)
            if parser.has_option(GUI_SECTION, WINDOW_GEOMETRY):
                self.windowgeo = parser.get(GUI_SECTION, WINDOW_GEOMETRY)
        else:            
            # OK, so there's this weird thing I observed in wmii on
            # Linux that if I don't set the window geometry to
            # something, the window redraws when I change months take
            # forever - there's this huge delay.  If I set it to
            # anything at all, everything is cool.  I don't get this
            # in KDE, and I have no idea if it is a problem in Win32
            # or OS X, but this is in for now to be safe.
            self.windowgeo = "600x400+0+0"

    def writeconfig(self):
        """
        Writes some user options out to the default configuration file.
        """

        parser = ConfigParser.ConfigParser()
        windowgeo = self.root.geometry()
        parser.add_section(GUI_SECTION)
        parser.set(GUI_SECTION, WINDOW_GEOMETRY, windowgeo)
        parser.write(open(CONFIG_FILE, 'w'))

    def save(self):
        self.synctocal()
        self.manager.post(persistdataevent())
        self.root.title(APP_TITLE)
        self.editted = False

    def quit(self):
        """
        Cleans up and quits the GUI.

        This is pretty much a trap for the quit action so that we have
        a chance to sync all the changes made in the GUI back into the
        calendar object.
        """
        self.synctocal()
        self.writeconfig()
        self.save()
        self.root.quit()

    def jumptodate(self, date):
        """
        General method that jumps to a specific date directly.
        """
        olddate = self.activedate
        self.activedate = date
        if self.activedate.month != olddate.month:
            self.manager.post(requestmonthdataevent(self.activedate))
            self.synctocal()
            self.main.grid_forget()
            self.drawcal()
        else:
            self.days[olddate].update(self.activedate)
            self.days[self.activedate].update(self.activedate)
        

    def move(self, delta):
        """
        Moves the activeday by the timedelta specified.
        """
        if self.editting:
            return
        self.jumptodate(self.activedate + delta)


    def backmonth(self):
        """
        Moves the active date to the same day in the previous month
        """
        if self.editting:
            return
        self.synctocal()
        # If any month other than January, we simply move back one month
        if self.activedate.month > 1:
            newyear = self.activedate.year
            newmonth = self.activedate.month-1
            newday = self.activedate.day
        # If January, back one month gives us December in the previous
        # year
        else:
            newyear = self.activedate.year-1
            newmonth = 12
            newday = self.activedate.day

        self.jumptodate(self.__fixdate(newday, newmonth, newyear))


    def forwardmonth(self):
        """
        Moves the active date to the same day in the next month
        """
        if self.editting:
            return
        # If any month other than December
        if self.activedate.month < 12:
            newyear = self.activedate.year
            newmonth = self.activedate.month+1
            newday = self.activedate.day
        # If December, we move to January in the next year
        else:
            newyear = self.activedate.year+1
            newmonth = 1
            newday = self.activedate.day

        self.jumptodate(self.__fixdate(newday, newmonth, newyear))


    def __fixdate(self, newday, newmonth, newyear):
        """
        If setting the newday to a day that doesn't exist,
        fix the date to be a date that does exist
        i.e. Calling backmonth with an activedate of 31 March
        would result in an activedate of 31 February.  This
        doesn't work, so we check for it and fix it.

        Return a date that is a valid day in the given month
        and year, as close as possible to the supplied day.
        """

        if newday > calendar.monthrange(newyear, newmonth)[1]:
            newday = calendar.monthrange(newyear, newmonth)[1]

        return self.activedate.replace(day=newday, month=newmonth, year=newyear)

    def synctocal(self):
        """
        Syncronizes all text in the DayWidgets back to the calendar object.
        """
        for day in self.days.values():
            text = day.gettext()
            self.manager.post(setdaytextevent(day.date, text))
        

    def drawcal(self):
        """
        Master method to draw the entire calendar window.

        A lot of GUI trash is stuffed in here.  There are probably
        opportunities for refactoring, but GUI code is notoriously
        awful, so I don't feel that bad.
        """
        self.main = Tkinter.Frame(self.root)
        ######################################################################
        # Craziness to get resizing to work - I hate Tk
        top = self.main.winfo_toplevel()
        top.rowconfigure(0, weight=1)
        top.columnconfigure(0, weight=1)
        self.main.rowconfigure(1, weight=1)
        self.main.columnconfigure(0, weight=1)
        ######################################################################

        ######################################################################
        # Set up title bar
        titletext = str(self.currentmonthstr()) + " " + str(self.currentyearstr())
        titlebar = Tkinter.Frame(self.main)
        titleback = Tkinter.Button(titlebar, text="<<", \
                                   command=self.backmonth).grid(row=0, column=0)
        titlelabel = Tkinter.Label(titlebar, text=titletext, \
                                   bg=MONTH_BG).grid(row=0, column=1, sticky=E+W)
        titleforward = Tkinter.Button(titlebar, text=">>", \
                                      command=self.forwardmonth).grid(row=0, column=2)
        # The center column will be the one to resize to fit.
        titlebar.columnconfigure(1, weight=1)
        titlebar.grid(row=0, sticky=E+W)
        ######################################################################

        ######################################################################
        # Set up the calendar window
        calwindow = Tkinter.Frame(self.main, borderwidth=1, relief=RAISED)
        month = calendar.monthcalendar(self.activedate.year,
                                       self.activedate.month)

        # Set minimum column size for each day of the week.
        # Also, set the weight option so that all columns are resized equally
        for i in range(7):
            calwindow.columnconfigure(i, minsize=self.columnminsize, weight=1)

        # Set up weekday header bar
        for i in range(len(self.weekdays)):
            d = Tkinter.Label(calwindow, text=self.weekdays[i], bg=WEEKDAY_HEADER_BG)
            d.grid(row=0, column=i, sticky=E+W)

        # Set up all the days in their grid.  This is the core of the calendar
        # drawing logic.
        # We use old-school index based looping because we want
        # to determine placement of the days in the grid layout using the
        # indexes.
        self.days = {}
        for week in range(len(month)):
            for day in range(len(month[week])):
                if month[week][day] != 0:
                    d = datetime.date(self.activedate.year,
                                  self.activedate.month,
                                  month[week][day])
                    try:
                        t = self.eventlist[d]
                    except KeyError:
                        t= ""
                    dayicon = DayWidget(calwindow, manager, d,
                                             self.activedate,
                                             text=t)
                    # Magic +1 for row because of weekday header take a row
                    dayicon.grid(row=week+1, column=day)
                    # To access the day objects later, we stores references
                    # to them in a dictionary.
                    self.days[d] = dayicon
            # A good opportunity to take care of the resizing issues, since
            # we're already making a pass through the rows.  Tk is lame.
            calwindow.rowconfigure(week+1, weight=1)

        calwindow.grid(row=1, sticky=N+E+W+S)
        ######################################################################

        self.main.grid(sticky=N+E+W+S)

    # Plugin code
    def initializePlugins(self):
        """
        Locates and loads any available plugins for the application.
        """
        pluginFiles = []
        
        try:
            pluginFiles = [file for file in os.listdir(PLUGIN_DIR) if file.endswith(".py")]
        except OSError:
            pass #It is OK if the plugin directory does not exist
        if pluginFiles: # if non-empty
            for plugin in pluginFiles:
                execfile(os.path.join(PLUGIN_DIR, plugin), globals())
        
        

class DayWidget(Tkinter.Frame):
    """
    Graphical representation of a day in the calendar.
    """

    def __init__(self, parent, manager, date, activedate, text=None):

        Tkinter.Frame.__init__(self, parent, borderwidth=1, relief=RAISED)

        self.manager = manager
        self.date = date
        self.text = text

        self.daytitle = Tkinter.Label(self, text=str(self.date.day),
                                      anchor=W, justify=LEFT,
                                      fg=DAY_TITLE_FG,
                                      bg=self.__getbgcolor(activedate))
        self.daytitle.grid(row=0, sticky=W+E)

        self.dayevents = Tkinter.Text(self, width=15, height=2, bg=EVENTS_BG)
        if self.text!=None:
            self.dayevents.insert(END, self.text)
        self.dayevents.grid(row=1, columnspan=2, sticky=N+E+W+S)

        self.dayevents.bind("<Button-1>", lambda(event): self.takefocus(), "")

        ###
        # More resizing fixes.  Tk sucks.
        self.rowconfigure(1, weight=1)
        self.columnconfigure(0, weight=1)
        ###

        self.grid(sticky=N+E+W+S)

    def update(self, activedate):
        """
        Updates the color of the titlebar to reflect whether this
        is the active day.
        """
        self.daytitle.config(bg=self.__getbgcolor(activedate))

    def __getbgcolor(self, activedate):
        # Day title coloring depends on whether we are the activedate or not.
        bgcolor = DAY_TITLE_BG
        # Determine if we are the active day
        if self.date == activedate:
            bgcolor = DAY_TITLE_ACTIVE_BG
        return bgcolor

    def gettext(self):
        """
        Returns the text of the day's events as a string.

        Currently, we use a generic (cross-platform) whitespace
        strip to accomplish this.
        """
        # We need to strip off trailing \n characters.
        return self.dayevents.get("1.0", END).rstrip()

    def takefocus(self):
        self.manager.post(setactivedateevent(self.date))
        self.manager.post(startedittingevent())
        self.dayevents.focus()



######################################################################
# Main
######################################################################
if __name__ == "__main__":
    manager = eventmanager()
    calobject = cal(SAVE_FILE)
    calobject.setmanager(manager)
    view = CalendarView(manager)
